#!/usr/bin/env ruby

require 'dbi'

require 'simmon/logging'
require 'simmon/sensor'

module Simmon

  # Abstract baseclass for MySQL sensors. It will attempt to keep a
  # persistent connection (to avoid spurious failures when max
  # connections is reached). Subclasses invoke with_connection() to
  # take advantage of the functionality.
  class MysqlSensor < Sensor
    def initialize(name,
                   host,
                   port,
                   database,
                   username,
                   password,
                   interval = 60,
                   maxlag = interval * 2,
                   initial_state = State.new(true))
      super(name, interval, maxlag, initial_state)
      
      @host = host
      @port = port
      @database = database
      @username = username
      @password = password

      @connection = nil

      @log = Log4r::Logger['simmon']
    end

    def connect()
      DBI.connect("DBI:MySQL:#{@database}:#{@host}:#{@port}}",
                  @username,
                  @password)
    end

    # Takes a block whose first and only parameter is an open database
    # handle to use for sampling. If the block fails with an exception
    # it is assumed that the connection may be lost or otherwise
    # in a non-working state, and will be automatically reconnected
    # for the next attempt. Otherwise a persistent connection is used.
    #
    # If establishment of a connection failes an exception will be
    # raised (whichever is raised by the dbd) prior to the invokation
    # of the block.
    def with_connection(&block)
      if not @connection then
        @connection = connect()
      end

      begin
        block.call(@connection)
      rescue
        begin
          @connection.close()
        rescue
        end
        @connection = nil
        raise
      end
    end
  end

  # Minimalistic sensor that simply tests whether the MySQL server is
  # accepting connections and is able to execute a trivial query.
  class MysqlConnectionSensor < MysqlSensor
    def sample()
      self.with_connection() do |conn|
        conn.transaction() do |txn|
          txn.execute("SELECT 1");
        end
        State.new(true)
      end
    end
  end

  # Monitors the master status of a MySQL server
  class MysqlMasterSensor < MysqlSensor
    def sample()
      self.with_connection() do |conn|
        state = State.new(false, nil, "No result from query")
        conn.transaction() do |txn|
          txn.execute("SHOW MASTER STATUS") do |result|
            result.fetch() do |row|
              state = State.new(true, [row['File'], row['Position']])
            end
          end
        end
        state
      end
    end
  end

  # Monitors the slave status of a MySQL server
  class MysqlSlaveSensor < MysqlSensor
    attr_reader :max_seconds_behind
    attr_writer :max_seconds_behind

    def initialize(*args)
      super(*args)

      @max_seconds_behind = 0
    end

    def sample()
      self.with_connection() do |conn|
        state = State.new(false, nil, "No result from query")
        conn.transaction() do |txn|
          txn.execute("SHOW SLAVE STATUS") do |result|
            result.fetch() do |row|
              log_file    = row['Master_Log_File']
              log_pos     = row['Read_Master_Log_Pos']
              secs_behind = row['Seconds_Behind_Master']

              if secs_behind != nil then
                @log.debug("mysql slave #{@host} reports #{secs_behind} Seconds_Behind_Master")
                
                ok = (secs_behind and (secs_behind <= @max_seconds_behind))
                
                state = State.new(ok,
                                  [log_file, log_pos, secs_behind],
                                  "seconds behind: #{secs_behind}")
              else
                @log.debug("mysql slave #{@host} reports NULL Seconds_Behind_Master - slave stopped?")

                state = State.new(false,
                                  [log_file, log_pos, secs_behind],
                                  "slave seems stopped - seconds behind is NULL (instead of an integer)")
              end
            end
          end
        end
        state
      end
    end
  end

  # Monitors the status of MySQL slaves relative to their master,
  # making sure replication has not been halted. Also monitors that
  # all are alive.
  class MysqlReplicationSensor < Sensor
    def initialize(name,
                   master_sensor,
                   slave_sensors,
                   graceperiod = 10,
                   interval = 60,
                   maxlag = interval * 2,
                   initial_state = State.new(true))
      super(name, interval, maxlag, initial_state)

      @master = master_sensor
      @slaves = slave_sensors
      @graceperiod = graceperiod
    end

    def log_file_number(filename)
      if not filename =~ /^.*\.(\d+)$/ then
        return State.new(false, nil, "Could not recognize filename format of master log file: #{filename}")
      end

      $1.to_i()
    end

    def sample()
      master_state = @master.state()

      if not master_state.ok() then
        State.new(false, nil, "master not ok: #{master_state}")
      else
        master_log_file = log_file_number(master_state.value()[0])
        master_log_pos  = master_state.value()[1].to_i()
      
        sleep(@graceperiod)
        
        failures = []
        
        @slaves.each() do |sensor|
          s = sensor.state()

          statestring = "#{s.message()}: "
          if s.value() then
            statestring = "#{statestring} #{s.value().join(", ")}"
          else
            statestring = "#{statestring} (no value)"
          end

          if s.ok() then
            log_file = log_file_number(s.value()[0])
            log_pos  = s.value()[1]
            secs_behind = s.value()[2]

            if log_file == master_log_file then
              if log_pos < master_log_pos then
                failures << { 'sensor' => sensor, 'reason' => "lagging behind: #{statestring}" }
              end
            elsif log_file < master_log_file
              failures << { 'sensor' => sensor, 'reason' => "lagging behind: #{statestring}" }
            end
          else
            failures << { 'sensor' => sensor, 'reason' => "not ok: #{statestring}" }
          end
        end

        if failures.empty?() then
          State.new(true, nil, "All claim up, and slave log positions match that of master.")
        else
          reasons = []
          failures.each() do |f|
            reasons << "#{f['sensor'].name()}: #{f['reason']}"
          end
          State.new(false, nil, "#{failures.size()} slaves not OK: #{reasons}")
        end
      end
    end
  end
end
